feat(blob): support placeholder fallback for partial updates - #453
Open
SteNicholas wants to merge 1 commit into
Open
feat(blob): support placeholder fallback for partial updates#453SteNicholas wants to merge 1 commit into
SteNicholas wants to merge 1 commit into
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds end-to-end support for data-evolution partial updates on BLOB columns by introducing placeholder entries (bin_length == -2) in the blob format and implementing a multi-layer fallback read path that merges newer/older blob layers row-by-row.
Changes:
- Extend blob write/read format to recognize placeholder entries and (optionally) emit an in-band placeholder sentinel for downstream fallback merging.
- Update
DataEvolutionSplitRead::BlobBunchto retain all blob files across max-sequence “layers” and introduce a newBlobFallbackBatchReaderto resolve placeholders across those layers. - Wire an internal per-reader format option through
AbstractSplitRead::CreateRawFileReadersand add unit + integration coverage for placeholder fallback, gaps, null-wins behavior, and row-range pushdown.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/inte/blob_table_inte_test.cpp | Adds end-to-end integration tests for partial-update placeholder fallback, null-wins semantics, descriptor mode, and row-range pushdown. |
| src/paimon/format/blob/blob_reader_builder.h | Plumbs internal option to enable placeholder-sentinel emission from the blob reader. |
| src/paimon/format/blob/blob_format_writer.cpp | Detects in-band placeholder sentinel and writes bin_length = -2 placeholder entries (no payload). |
| src/paimon/format/blob/blob_format_writer_test.cpp | Adds golden-bytes and strict vs placeholder-aware read-mode tests, including selection-bitmap coverage. |
| src/paimon/format/blob/blob_file_batch_reader.h | Extends blob reader API to optionally emit placeholder sentinel bytes instead of failing on placeholders. |
| src/paimon/format/blob/blob_file_batch_reader.cpp | Implements placeholder handling in offsets/contents building and strict-mode failure behavior. |
| src/paimon/core/operation/data_evolution_split_read.h | Updates BlobBunch to model layered blob files and declares fallback-reader construction. |
| src/paimon/core/operation/data_evolution_split_read.cpp | Keeps layered blob files, chooses concat vs fallback path, and builds padded per-layer segments for fallback merging. |
| src/paimon/core/operation/data_evolution_split_read_test.cpp | Updates/expands BlobBunch tests to validate layering rules, row-count behavior, and optimize-path selection. |
| src/paimon/core/operation/abstract_split_read.h | Adds extra_format_options plumbing to allow per-reader format option overrides (used for placeholder emission). |
| src/paimon/core/operation/abstract_split_read.cpp | Merges extra_format_options over table options when instantiating file formats/readers. |
| src/paimon/common/reader/blob_fallback_batch_reader.h | Introduces the fallback batch reader interface and contracts for layered placeholder resolution. |
| src/paimon/common/reader/blob_fallback_batch_reader.cpp | Implements row-wise fallback merging across sequence layers with gap padding and schema validation. |
| src/paimon/common/reader/blob_fallback_batch_reader_test.cpp | Adds unit tests covering multi-layer fallback, gaps, all-placeholder => null, null-wins, and validation/misalignment failures. |
| src/paimon/common/data/blob_defs.h | Defines placeholder bin length, sentinel bytes, internal option key, and sentinel predicate helper. |
| src/paimon/CMakeLists.txt | Registers the new reader source and unit test in the build. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
SteNicholas
force-pushed
the
PAIMON-452
branch
8 times, most recently
from
July 28, 2026 11:47
bc858d0 to
03c2d22
Compare
lxy-9602
reviewed
Jul 29, 2026
lxy-9602
reviewed
Jul 29, 2026
SteNicholas
force-pushed
the
PAIMON-452
branch
3 times, most recently
from
July 30, 2026 11:39
3bafa69 to
2dc1ee0
Compare
SteNicholas
force-pushed
the
PAIMON-452
branch
3 times, most recently
from
July 30, 2026 13:44
3f9c337 to
07143f2
Compare
lxy-9602
reviewed
Jul 30, 2026
A data-evolution partial update rewrites only the touched rows of a blob column and records every untouched row as a placeholder entry (bin_length -2, no data bytes). The placeholder protocol is confined to two internal channels. On write, only a data-evolution blob-only column write enables it (BlobDefs::kWritePlaceholderKey); under it a value exactly equal to the internal reserved sentinel _PAIMON_BLOB_PLACEHOLDER is persisted as a bin_length -2 entry. Every other write stores blob bytes verbatim. On read, placeholder entries are an error unless BlobDefs::kEmitPlaceholderSentinelKey switches the reader to emit the sentinel for them. Placeholders are identified by exact byte equality with the sentinel, so a user blob whose bytes equal it collides with the marker: written through the partial-update channel it is persisted as a placeholder entry that a single-layer read rejects loudly, and left untouched in an older layer under a later partial update it reads as a placeholder in every layer and silently degrades to a null blob. These collisions are accepted as negligibly improbable and pinned by tests. The blob.internal.* keys are stripped from user-supplied table options on both the write and read paths, so only the internal channels can enable the protocol. BlobBunch keeps all max-sequence layers of a bunch, and the new BlobFallbackBatchReader resolves each row to the newest layer holding a real value: an explicitly written null wins over older layers, a row that is a placeholder in every layer degrades to a null blob while keeping its _ROW_ID and reporting -1 as its _SEQUENCE_NUMBER, resolved rows report their layer's sequence number, and row-range pushdown is honored including the row id ranges a layer does not cover. BlobFileBatchReader::GetPreviousBatchFileRowId now maps batch positions back to original file row indexes through target_blob_row_indexes_, so _ROW_ID completion keeps working when a selection bitmap removed rows. CompleteRowTrackingFieldsBatchReader takes the physical field names of a format without a self-describing schema (blob) from DataFileMeta::write_cols instead of treating NotImplemented as an empty file schema. DataEvolutionFileReader::Create accepts a single inner reader: a blob-only write collapses every layer of a merge split into one blob bunch, and the degenerate union still maps the bunch's fields into read schema order and null fills unmatched read fields. Reader offsets are now validated against the reader count instead of requiring at least two readers. DataEvolutionBatchScan::WrapToIndexedSplits computed a split's row id range from its first and last file only, so a partial-update blob layer covering a strict subrange of the split truncated the range and silently dropped requested row ranges at scan time. Because the row-id ranges of the files in a split may be unordered, discontiguous, or overlapping, the index is now intersected with each file's row-id range separately and the results are sorted and merged, aligning with Java's DataEvolutionBatchScan.wrap(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Linked issue: close #452
A data-evolution partial update rewrites only the touched rows of a blob column and records every untouched row as a placeholder entry (
bin_length -2, no data bytes). This PR makes the whole chain understand such entries:blob.internal.write-placeholder(BlobDefs::kWritePlaceholderKey,falseby default), enabled only for data-evolution partial updates, i.e. blob-only column writes of a table with data evolution enabled. Under the protocol, a value exactly equal to the internal reserved sentinel_PAIMON_BLOB_PLACEHOLDER(BlobDefs::kPlaceholderSentinel, exposed asBlobDefs::PlaceholderSentinelView()) is persisted as abin_length -2entry without payload bytes; any other value is stored verbatim. Outside the protocol the writer never interprets values at all.blob.internal.emit-placeholder-sentinel(BlobDefs::kEmitPlaceholderSentinelKey,falseby default, set only by the fallback read path) switches it to emit the sentinel bytes for-2entries, so after the batch has passed through schema-mapping readers, the fallback merge can identify placeholders by exact byte equality. There is no escaping anywhere in the chain: inside those two internal channels a user blob whose bytes equal the sentinel is indistinguishable from a placeholder and would be persisted as one. The sentinel is distinctive enough that this collision is accepted as negligibly improbable (documented onBlobDefs::kPlaceholderSentinel). Sentinel bytes are never stored in blob files.BlobBunch: keeps all blob files of a bunch, where files sharing amax_sequence_numberform one non-overlapping layer; overlaps across layers are the expected shape of partial updates. When every file shares a singlemax_sequence_number,SequentialReadOptimize()keeps the existing concat fast path.BlobFallbackBatchReader(new, aligned with Java'sBlobFallbackRecordReader/AllPlaceholdersRecordReader): merges the layers of one blob bunch ordered bymax_sequence_numberdescending; row-id ranges a layer does not cover are padded with placeholder gap segments so all layers step in lockstep; each output row takes the newest non-placeholder layer, and an explicitly written null wins over older layers. A row that is a placeholder in every layer degrades to a null blob while keeping its_ROW_IDand reporting-1as its_SEQUENCE_NUMBER; resolved rows report their layer's sequence number. Row-range pushdown is honored per layer, including inside gaps. Unlike Java'sForceSingleBatchReader, the layers are stepped in aread_batch_sizewindow rather than materialized as a single batch.BlobFileBatchReader:GetPreviousBatchFileRowIdnow stays usable when a selection bitmap removed rows, mapping batch positions back to original file row indexes throughtarget_blob_row_indexes_— required byCompleteRowTrackingFieldsBatchReaderto synthesize_ROW_IDunder row-range pushdown.CompleteRowTrackingFieldsBatchReader: takes an explicit optionalfile_field_names(fromDataFileMeta::write_cols) declaring the physical fields of a format without a self-describing file schema (blob); self-describing formats keep queryingGetFileSchema(). UnrelatedNotImplementederrors stay visible instead of being treated as an empty schema.DataEvolutionSplitRead: builds the fallback reader when a blob bunch spans multiple sequence layers, and passes the internal format options throughAbstractSplitRead::CreateRawFileReaders(explicitextra_format_optionsparameter, no default arguments).DataEvolutionFileReader:Createaccepts a single inner reader — a blob-only write collapses every layer of a merge split into one blob bunch, and the degenerate union still maps the bunch's fields into read schema order and null fills unmatched read fields. Reader offsets are now validated against the reader count instead of requiring at least two readers.DataEvolutionBatchScan:WrapToIndexedSplitsderived a split's row id range from its first and last file only. That bounding range relies on an invariant that does not hold — the row-id ranges of the files in a split may be unordered, discontiguous or overlapping, sinceDataEvolutionSplitGeneratorcan bin-pack disjoint groups into one split — so it both admitted row ids no file of the split covers and, for a partial-update blob layer covering a strict subrange, truncated the range and silently dropped requested row ranges at scan time. The index is now intersected with each file's row-id range separately and the results are sorted and merged (Range::SortAndMergeOverlap(..., adjacent=true)), aligning with Java'sDataEvolutionBatchScan.wrap().WrapToIndexedSplitsbecomesstaticand visible for testing.Tests
blob_format_writer_test.cpp: placeholder golden bytes aligned with the Java writer, strict vs placeholder-aware read modes (including descriptor mode and selection bitmaps), and sentinel-equal / sentinel-prefixed user bytes stored verbatim (TestWritePlaceholderGoldenBytes,TestReadPlaceholderStrictAndAwareModes,TestReadPlaceholderWithSelectionBitmap,TestSentinelBytesVerbatimWithoutPlaceholderMode,TestSentinelPrefixedValueVerbatimInPlaceholderMode).blob_file_batch_reader_test.cpp: batch positions map back to original file row indexes under a selection bitmap (TestRowNumbersWithSelectionBitmap).complete_row_tracking_fields_reader_test.cpp: declared physical fields take precedence over the inner reader's self-described schema (TestSetReadSchemaWithDeclaredFileFields); existing cases keep exercising the self-describing branch.blob_fallback_batch_reader_test.cpp(new): fallback merge across layers under batch-size sweeps — gaps (leading/middle/trailing, disjoint ranges), all-placeholder rows degrading to null, null-wins semantics, sentinel-prefixed pass-through, three layers, row-tracking projections including the all-placeholder-1semantics, and misaligned-group / creation failures.data_evolution_batch_scan_test.cpp(new):WrapToIndexedSplitsover a split whose files have unordered and discontiguous row-id ranges (mirroring Java'stestWrapToIndexSplitsWithUnorderedAndDiscontiguousDataFiles), row ids falling into the gaps between file ranges being excluded, and the no-intersection failure (TestWrapToIndexedSplitsWithUnorderedAndDiscontiguousDataFiles,TestWrapToIndexedSplitsExcludesRowIdsInFileRangeGaps,TestWrapToIndexedSplitsWithoutIntersection).data_evolution_split_read_test.cpp:BlobBunchkeeps all sequence layers, rejects overlaps within one layer, and reportsRowCount/SequentialReadOptimizeaccordingly.data_evolution_file_reader_test.cpp: creation rejects empty readers and reader offsets referencing a missing reader, replacing the old at-least-two-readers requirement (TestInvalid).blob_table_inte_test.cpp(IT): end-to-end partial-update reads — placeholder fallback with null overwrite and ablob_as_descriptorvariant, multi-layer and compacted-layer layouts (mirroring Java'sBlobUpdateTest.testReadCompactedBlobSequenceGroups) with per-row and gap-crossing row-range reads, row-range pushdown over updated and untouched rows, a subrange sequence layer under the row-tracking projection with row-range pushdown, an all-placeholder row keeping its_ROW_IDwith_SEQUENCE_NUMBER -1, and sentinel-equal / sentinel-prefixed user blobs passing through unchanged (TestDataEvolutionBlobPartialUpdate*,TestBlobValueEqualToPlaceholderSentinelBytes). The row-tracking tests write only the blob column, so the split contains only blob files and the blob bunch itself carries the row-tracking fields.API and Format
include/paimon.bin_length -2marks a placeholder entry occupying no file space, aligned with Java'sBlobFormatWriter.PLACE_HOLDER_LENGTH. Readers without placeholder support already reject such entries with an explicit error, and files without placeholders are unaffected.blob_file_batch_reader.h) now documents both special index values:-1(null) and-2(placeholder)._PAIMON_BLOB_PLACEHOLDERis an internal reserved value only inside the two internal placeholder channels; normal writes and reads never interpret it.falseby default:blob.internal.write-placeholder(set only for data-evolution blob-only column writes) andblob.internal.emit-placeholder-sentinel(set only by the data-evolution fallback read path).Documentation
Internal read/write behavior of data-evolution partial updates; no user-facing documentation change.
Generative AI tooling
Generated-by: Claude Code (Claude Fable 5)
🤖 Generated with Claude Code